查看原文
其他

光影的魔法!Cocos Creator 实现屏幕空间的环境光遮蔽(SSAO)

alpha COCOS 2022-06-10

引言:

本文作者 alpha 从事游戏前端开发已经5年,毕业后他先是入职了腾讯无线大连研发中心,而后开启了北漂生涯,在北京的这3年一直都在使用 Cocos Creator,对前端业务,包体、内存优化有很多的实践经验。最近 alpha 在学习计算机图形学相关技术,今天他将同大家分享 Cocos Creator 3.3 实现屏幕空间的环境光遮蔽(SSAO)的技术经验。


什么是 AO ?


环境光(Ambient Lighting)是场景总体光照中的一个固定光照常量,用来模拟光的散射(Scattering)。在现实中,光线会以任意方向散射,它的强度是会改变的。


其中一种间接光照的模拟叫做环境光遮蔽(Ambient Occlusion),它的原理是通过将褶皱、孔洞和非常靠近的墙面变暗的方法近似模拟出间接光照。这些区域很大程度上是被周围的几何体遮挡的,所以这些地方看起来会更暗一些。


在2007年,Crytek 公司发布了一款叫做屏幕空间环境光遮蔽(Screen Space Ambient Occlusion,SSAO)的技术,并用在了他们的看家作孤岛危机上。这一技术使用了屏幕空间场景的深度而不是真实的几何体数据来确定遮蔽量。这一做法相对于真正的环境光遮蔽(基于光线追踪)不但速度快,而且还能获得较好的效果,使得它成为近似实时环境光遮蔽的标准。


下面这幅图展示了在使用和不使用 SSAO 时场景的不同。特别注意对比电话亭后面和墙角部分,你会发现(环境)光被遮蔽了许多:


虽然这个效果不是非常明显,但是启用 AO 确实给我们更真实的感觉,这些小的遮蔽细节能让整个场景看起来更有立体感。


SSAO 原理


SSAO 背后的原理很简单:对于屏幕上的每一个片段,会根据周边深度值计算一个遮蔽因子(Occlusion Factor)。这个遮蔽因子之后会被用来决定片段的环境光分量。遮蔽因子是通过采集片段周围球型核心(Kernel)的多个深度样本,并和当前片段深度值对比而得到的。高于片段深度值样本的个数就是我们想要的遮蔽因子。



上图中在几何体内灰色的深度样本都是高于片段深度值的,他们会增加遮蔽因子;几何体内样本个数越多,片段获得的环境光照也就越少。


很明显,渲染效果的质量和精度与采样的样本数量有直接关系。如果样本数量太低,渲染的精度会急剧减少,会得到一种叫做波纹(Banding)的效果;如果它太高了,会影响性能。通过引入随机性到采样核心(Sample Kernel)从而减少样本的数目。通过随机旋转采样核心,能在有限样本数量中得到高质量的结果。然而随机性引入了一个很明显的噪声图案,需要通过模糊降噪来修复这一问题。下面这幅图片展示了波纹效果还有随机性造成的效果:


可以看到,尽管在低样本数的情况下得到了很明显的波纹效果,引入随机性之后这些波纹效果就完全消失了。最初 Crytek 的实现是用一个深度缓冲做为输入,但是这种方式存在一些问题(如自遮闭, 光环),由于这个原因,现在通常不会使用球体的采样核心,而是使用一个沿着表面法向量的半球体采样核心。



通过在法向半球体(Normal Oriented Hemisphere)周围采样,将不会考虑到片段背面的几何体,它消除了环境光遮蔽灰蒙蒙的感觉,从而产生更真实的结果。


SSAO 特点:
  • 独立于场景复杂性,仅和投影后最终的像素有关,和场景中的顶点数三角数无关。
  • 跟传统的 AO 处理方法相比,不需要预处理,无需加载时间,也无需系统内存中的内存分配,所以更加适用于动态场景。
  • 对屏幕上的每个像素以相同的一致方式工作。
  • 没有 CPU 使用 - 它可以在 GPU 上完全执行。
  • 可以轻松集成到任何现代图形管线中。




在了解了 AO & SSAO 之后,我们来看看要怎么基于 Cocos Creator 3.3.1 实现 SSAO


Demo 地址:

https://gitee.com/yanjifa/cc-ssao-demo


样本缓冲


SSAO 需要几何体的信息来确定一个片段的遮蔽因子,对于每个片段(像素),需要如下数据:
  • 逐片段位置向量

  • 逐片段法线向量

  • 逐片段反射颜色

  • 采样核心

  • 用来旋转采样核心的随机旋转向量


通过使用一个逐片段观察空间位置,可以将一个采样半球核心对准片段的观察空间表面法线。对于每一个核心样本会采样线性深度纹理来比较结果。采样核心会根据旋转矢量稍微偏转一点;所获得的遮蔽因子将会之后用来限制最终的环境光照分量。



通过以上发现 SSAO 所需的数据不正是延迟管线的 G-buffer,关于 G-buffer 是什么可通过文章「延迟着色法」[1]做一个简单的了解。阅读引擎代码 editor/assets/chunks/standard-surface-entry-entry.chunk 和 cocos/core/pipeline/define.ts :

// editor/assets/chunks/standard-surface-entry-entry.chunk 33 行附近
#elif CC_PIPELINE_TYPE == CC_PIPELINE_TYPE_DEFERRED

    layout(location = 0) out vec4 fragColor0;
    layout(location = 1) out vec4 fragColor1;
    layout(location = 2) out vec4 fragColor2;
    layout(location = 3) out vec4 fragColor3;

    void main () {
        StandardSurface s; surf(s);
        fragColor0 = s.albedo;                         // 漫反射颜色 -> 反照率纹理
        fragColor1 = vec4(s.position, s.roughness);    // 位置 -> 世界空间位置
        fragColor2 = vec4(s.normal, s.metallic);       // 法线 -> 世界空间法线
        fragColor3 = vec4(s.emissive, s.occlusion);    // 和本文无关, 不做介绍
    }
#endif

// cocos/core/pipeline/define.ts  117 行 附近
export enum PipelineGlobalBindings {
    UBO_GLOBAL,
    UBO_CAMERA,
    UBO_SHADOW,

    SAMPLER_SHADOWMAP,
    SAMPLER_ENVIRONMENT,
    SAMPLER_SPOT_LIGHTING_MAP,
    SAMPLER_GBUFFER_ALBEDOMAP,   // 6
    SAMPLER_GBUFFER_POSITIONMAP, // 7
    SAMPLER_GBUFFER_NORMALMAP,   // 8
    SAMPLER_GBUFFER_EMISSIVEMAP,
    SAMPLER_LIGHTING_RESULTMAP,

    COUNT,
}

// cocos/core/pipeline/define.ts  283 行 附近
const UNIFORM_GBUFFER_ALBEDOMAP_NAME = 'cc_gbuffer_albedoMap';
export const UNIFORM_GBUFFER_ALBEDOMAP_BINDING = PipelineGlobalBindings.SAMPLER_GBUFFER_ALBEDOMAP; // 6
// ...

const UNIFORM_GBUFFER_POSITIONMAP_NAME = 'cc_gbuffer_positionMap';
export const UNIFORM_GBUFFER_POSITIONMAP_BINDING = PipelineGlobalBindings.SAMPLER_GBUFFER_POSITIONMAP; // 7
// ...

const UNIFORM_GBUFFER_NORMALMAP_NAME = 'cc_gbuffer_normalMap';
export const UNIFORM_GBUFFER_NORMALMAP_BINDING = PipelineGlobalBindings.SAMPLER_GBUFFER_NORMALMAP; // 8
// ...


通过以上代码可以分析出引擎 G-buffer 的数据布局,和具体 G-buffer 数据内容,深度值后面将会使用 G-buffer 计算得出。


自定义渲染管线


通过扩展延迟渲染管线的方式,在内置渲染管线的 LightFlow 上增加 一个 SsaoStage 用来生成 AO 纹理。首先创建一个渲染管线资源,资源管理器右键->创建->Render Pipeine->Render Pipeline Asset,命名为 ssao-deferrd-pipeline,创建 ssao-material | ssao-effect 着色器用来计算 AO 纹理,完整文件如下:

.
├── ssao-constant.chunk            // UBO 描述
├── ssao-deferred-pipeline.rpp     // 管线资源文件
├── ssao-effect.effect             // ssao shader
├── ssao-lighting.effect           // 光照 shader, 直接拷贝内置 internal/effects/pipeline/defferrd-lighting
├── ssao-lighting.mtl
├── ssao-material.mtl
├── ssao-render-pipeline.ts        // 定制管线脚本
├── ssao-stage.ts                  // stage 脚本
└── uboDefine.ts                   // Uniform Buffer Object 定义脚本


对应管线配置如下,在 LightingFlow 下 Stages 最前面加入 SsaoStage,并指定对应的材质,可以看到,引擎现在其实已经支持后处理(PostProcess)了,只要指定材质就可以了,可能当前版本还不完善,所以引擎组还没公开,其实 SSAO 也可以算是一种后处理效果,管线资源的属性设置如下:


自定义管线脚本如下:

// uboDefine.ts
import { gfx, pipeline } from "cc";
const { DescriptorSetLayoutBinding, UniformSamplerTexture, DescriptorType, ShaderStageFlagBit, Type } = gfx;
const { SetIndex, PipelineGlobalBindings, globalDescriptorSetLayout } = pipeline;

let GlobalBindingStart = PipelineGlobalBindings.COUNT; // 11
let GlobalBindingIndex = 0;
/**
* 定义 SSAO Frame Buffer, 布局描述
*/
const UNIFORM_SSAOMAP_NAME = 'cc_ssaoMap';
export const UNIFORM_SSAOMAP_BINDING = GlobalBindingStart + GlobalBindingIndex++; // 11
const UNIFORM_SSAOMAP_DESCRIPTOR = new DescriptorSetLayoutBinding(UNIFORM_SSAOMAP_BINDING, DescriptorType.SAMPLER_TEXTURE, 1, ShaderStageFlagBit.FRAGMENT);
const UNIFORM_SSAOMAP_LAYOUT = new UniformSamplerTexture(SetIndex.GLOBAL, UNIFORM_SSAOMAP_BINDING, UNIFORM_SSAOMAP_NAME, Type.SAMPLER2D, 1);
globalDescriptorSetLayout.layouts[UNIFORM_SSAOMAP_NAME] = UNIFORM_SSAOMAP_LAYOUT;
globalDescriptorSetLayout.bindings[UNIFORM_SSAOMAP_BINDING] = UNIFORM_SSAOMAP_DESCRIPTOR;

/**
 * 采样核心、相机远近裁剪面 near & far 等 UniformBlock 布局描述
 */
export class UBOSsao {
    public static readonly SAMPLES_SIZE = 64; // 最大采样核心

    public static readonly CAMERA_NEAR_FAR_LINEAR_INFO_OFFSET = 0;
    public static readonly SSAO_SAMPLES_OFFSET = UBOSsao.CAMERA_NEAR_FAR_LINEAR_INFO_OFFSET + 4;

    public static readonly COUNT = (UBOSsao.SAMPLES_SIZE + 1) * 4;
    public static readonly SIZE = UBOSsao.COUNT * 4;

    public static readonly NAME = 'CCSsao';
    public static readonly BINDING = GlobalBindingStart + GlobalBindingIndex++; // 12
    public static readonly DESCRIPTOR = new gfx.DescriptorSetLayoutBinding(UBOSsao.BINDING, gfx.DescriptorType.UNIFORM_BUFFER, 1, gfx.ShaderStageFlagBit.ALL);
    public static readonly LAYOUT = new gfx.UniformBlock(SetIndex.GLOBAL, UBOSsao.BINDING, UBOSsao.NAME, [
        new gfx.Uniform('cc_cameraNFLSInfo', gfx.Type.FLOAT4, 1), // vec4
        new gfx.Uniform('ssao_samples', gfx.Type.FLOAT4, UBOSsao.SAMPLES_SIZE), // vec4[64]
    ], 1);
}
globalDescriptorSetLayout.layouts[UBOSsao.NAME] = UBOSsao.LAYOUT;
globalDescriptorSetLayout.bindings[UBOSsao.BINDING] = UBOSsao.DESCRIPTOR;
/**
 *  ssao-render-pipeline.ts
 *  扩展延迟渲染管线
 */
import { _decorator, DeferredPipeline, gfx, renderer } from "cc";
import { UNIFORM_SSAOMAP_BINDING } from "./uboDefine";
const { ccclass } = _decorator;

const _samplerInfo = [
    gfx.Filter.POINT,
    gfx.Filter.POINT,
    gfx.Filter.NONE,
    gfx.Address.CLAMP,
    gfx.Address.CLAMP,
    gfx.Address.CLAMP,
];

const samplerHash = renderer.genSamplerHash(_samplerInfo);

export class SsaoRenderData {
    frameBuffer?: gfx.Framebuffer | null;
    renderTargets?: gfx.Texture[] | null;
    depthTex?: gfx.Texture | null;
}

@ccclass("SsaoRenderPipeline")
export class SsaoRenderPipeline extends DeferredPipeline {
    private _width = 0;
    private _height = 0;

    private _ssaoRenderData: SsaoRenderData | null = null!;
    private _ssaoRenderPass: gfx.RenderPass | null = null;

    public activate(): boolean {
        const result = super.activate();
        this._width = this.device.width;
        this._height = this.device.height;
        this._generateSsaoRenderData();
        return result;
    }

    public resize(width: number, height: number) {
        if (this._width === width && this._height === height) {
            return;
        }
        super.resize(width, height);
        this._width = width;
        this._height = height;
        this._destroyRenderData();
        this._generateSsaoRenderData();
    }

    public getSsaoRenderData(camera: renderer.scene.Camera): SsaoRenderData {
        if (!this._ssaoRenderData) {
            this._generateSsaoRenderData();
        }
        return this._ssaoRenderData!;
    }

    /**
     * 核心代码, 创建一个 FrameBuffer 存储 SSAO 纹理
     */
    private _generateSsaoRenderData() {
        if (!this._ssaoRenderPass) {
            const colorAttachment = new gfx.ColorAttachment();
            colorAttachment.format = gfx.Format.RGBA8;
            colorAttachment.loadOp = gfx.LoadOp.CLEAR;
            colorAttachment.storeOp = gfx.StoreOp.STORE;
            colorAttachment.endAccesses = [gfx.AccessType.COLOR_ATTACHMENT_WRITE];

            const depthStencilAttachment = new gfx.DepthStencilAttachment();
            depthStencilAttachment.format = this.device.depthStencilFormat;
            depthStencilAttachment.depthLoadOp = gfx.LoadOp.CLEAR;
            depthStencilAttachment.depthStoreOp = gfx.StoreOp.STORE;
            depthStencilAttachment.stencilLoadOp = gfx.LoadOp.CLEAR;
            depthStencilAttachment.stencilStoreOp = gfx.StoreOp.STORE;

            const renderPassInfo = new gfx.RenderPassInfo([colorAttachment], depthStencilAttachment);
            this._ssaoRenderPass = this.device.createRenderPass(renderPassInfo);
        }

        this._ssaoRenderData = new SsaoRenderData();
        this._ssaoRenderData.renderTargets = [];
        // 因为 SSAO 纹理最终是一张灰度图, 所以使用 Format.R8 单通道纹理, 减少内存占用, 使用时只需要读取 R 通道即可
        this._ssaoRenderData.renderTargets.push(this.device.createTexture(new gfx.TextureInfo(
            gfx.TextureType.TEX2D,
            gfx.TextureUsageBit.COLOR_ATTACHMENT | gfx.TextureUsageBit.SAMPLED,
            gfx.Format.R8,
            this._width,
            this._height,
        )));

        this._ssaoRenderData.depthTex = this.device.createTexture(new gfx.TextureInfo(
            gfx.TextureType.TEX2D,
            gfx.TextureUsageBit.DEPTH_STENCIL_ATTACHMENT,
            this.device.depthStencilFormat,
            this._width,
            this._height,
        ));
        this._ssaoRenderData.frameBuffer = this.device.createFramebuffer(new gfx.FramebufferInfo(
            this._ssaoRenderPass!,
            this._ssaoRenderData.renderTargets,
            this._ssaoRenderData.depthTex,
        ));

        this.descriptorSet.bindTexture(UNIFORM_SSAOMAP_BINDING, this._ssaoRenderData.frameBuffer.colorTextures[0]!);
        const sampler = renderer.samplerLib.getSampler(this.device, samplerHash);
        this.descriptorSet.bindSampler(UNIFORM_SSAOMAP_BINDING, sampler);
    }


    public destroy(): boolean {
        this._destroyRenderData();
        return super.destroy();
    }

    private _destroyRenderData() {
        if (!this._ssaoRenderData) {
            return;
        }
        if (this._ssaoRenderData.depthTex) {
            this._ssaoRenderData.depthTex.destroy();
        }
        if (this._ssaoRenderData.renderTargets) {
            this._ssaoRenderData.renderTargets.forEach((o) => {
                o.destroy();
            })
        }
        if (this._ssaoRenderData.frameBuffer) {
            this._ssaoRenderData.frameBuffer.destroy();
        }
        this._ssaoRenderData = null;
    }
}


通过项目设置修改渲染管线为自定义的 SSAO 管线:


采样核心


我们需要沿着表面法线方向生成大量的样本。就像前面介绍的那样,想要生成形成半球形的样本。由于对每个表面法线方向生成采样核心非常困难,也不合实际,所以将在切线空间(Tangent Space)内生成采样核心,法向量将指向正 z 方向。



假设有一个单位半球,生成一个拥有最大64样本值的采样核心:

// ssao-stage.ts
activate(pipeline: DeferredPipeline, flow: RenderFlow) {
    super.activate(pipeline, flow);

    const device = pipeline.device;


    this._sampleBuffer = device.createBuffer(new gfx.BufferInfo(
        gfx.BufferUsageBit.UNIFORM | gfx.BufferUsageBit.TRANSFER_DST,
        gfx.MemoryUsageBit.HOST | gfx.MemoryUsageBit.DEVICE,
        UBOSsao.SIZE,
        UBOSsao.SIZE,
    ));

    this._sampleBufferData = new Float32Array(UBOSsao.COUNT);

    const sampleOffset = UBOSsao.SSAO_SAMPLES_OFFSET / 4;
    // 64 样本值采样核心, 这里写的不太详细, 可结合 LearnOpenGL CN 的教程, 加深理解
    for (let i = 0; i < UBOSsao.SAMPLES_SIZE; i++) {
        let sample = new Vec3(
            Math.random() * 2.0 - 1.0,
            Math.random() * 2.0 - 1.0,
            Math.random() + 0.01, // 这里和原教程有点区别, Z 稍微增加一个很小的值, 可改善平面波纹(Banding)的效果, 可能会对精度造成影响
        );
        sample = sample.normalize();
        let scale = i / UBOSsao.SAMPLES_SIZE;
        // 通过插值, 将核心样本靠近原点分布
        scale = lerp(0.1, 1.0, scale * scale);
        sample.multiplyScalar(scale);
        const index = 4 * (i + sampleOffset);
        this._sampleBufferData[index + 0] = sample.x;
        this._sampleBufferData[index + 1] = sample.y;
        this._sampleBufferData[index + 2] = sample.z;
    }
    this._pipeline.descriptorSet.bindBuffer(UBOSsao.BINDING, this._sampleBuffer);
}


我们在切线空间中以-1.0到1.0为范围变换 x 和 y 方向,并以 0.0 和 1.0 为范围变换样本的 z 方向 (如果以-1.0到1.0为范围,取样核心就变成球型了)。由于采样核心将会沿着表面法线对齐,所得的样本矢量将会在半球里。通过权重插值,得到一个大部分样本靠近原点的核心分布。



获取深度数据


通过 G-buffer 中的 PostionMap 获取线性深度值:

float getDepth(vec3 worldPos) {
// 转到观察空间
vec3 viewPos = (cc_matView * vec4(worldPos.xyz, 1.0)).xyz;
// cc_cameraNFLSInfo.y -> 相机 Far, 通过 ssao-stage.ts 脚本更新
float depth = -viewPos.z / cc_cameraNFLSInfo.y;
return depth;
}


深度图如下:


SSAO 着色器


/**
* ssao-effect.effect
*/
CCProgram ssao-fs %{
  precision highp float;
  #include <cc-global>
  #include <cc-shadow-map-base>
  #include <ssao-constant>

  // 最大 64
  #define SSAO_SAMPLES_SIZE 64

  in vec2 v_uv;

  #pragma builtin(global)
  layout (set = 0, binding = 7) uniform sampler2D cc_gbuffer_positionMap;
  #pragma builtin(global)
  layout (set = 0, binding = 8) uniform sampler2D cc_gbuffer_normalMap;

  layout(location = 0) out vec4 fragColor;

  // 随机数 0.0 - 1.0
  float rand(vec2 uv, float dx, float dy)
  {
    uv += vec2(dx, dy);
    return fract(sin(dot(uv,  vec2(12.9898, 78.233))) * 43758.5453);
  }

  // 随机旋转采样核心向量
  vec3 getRandomVec(vec2 uv){
    return vec3(
      rand(uv, 0.0, 1.0) * 2.0 - 1.0,
      rand(uv, 1.0, 0.0) * 2.0 - 1.0,
      0.0
    );
  }

  // 获取线性深度
  float getDepth(vec3 worldPos) {
    vec3 viewPos = (cc_matView * vec4(worldPos.xyz, 1.0)).xyz;
    float depth = -viewPos.z / cc_cameraNFLSInfo.y;
    return depth;
  }

  // 深度图
  // void main () {
  //   vec3 worldPos = texture(cc_gbuffer_positionMap, v_uv).xyz;
  //   fragColor = vec4(getDepth(worldPos));
  // }

  void main () {
    vec3 worldPos = texture(cc_gbuffer_positionMap, v_uv).xyz;
    vec3 normal = texture(cc_gbuffer_normalMap, v_uv).xyz;
    vec3 randomVec = getRandomVec(v_uv);

    float fragDepth = -getDepth(worldPos);

    // 创建一个TBN矩阵,将向量从切线空间变换到观察空间
    vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal));
    vec3 bitangent = cross(normal, tangent);
    mat3 TBN = mat3(tangent, bitangent, normal);

    // 取样半径
    float radius = 1.0;
    float occlusion = 0.0;
    for(int i = 0; i < SSAO_SAMPLES_SIZE; ++i)
    {
      vec3 ssaoSample = TBN * ssao_samples[i].xyz;
      ssaoSample = worldPos + ssaoSample * radius;
      float aoDepth = -getDepth(ssaoSample);

      vec4 offset = vec4(ssaoSample, 1.0);
      offset      = (cc_matProj * cc_matView) * offset;   // 转换到裁剪空间
      offset.xyz /= offset.w;                             // 透视除法
      offset.xyz  = offset.xyz * 0.5 + 0.5;               // 从 NDC (标准化设备坐标, -1.0 - 1.0) 变换到 0.0 - 1.0

      vec3 samplePos = texture(cc_gbuffer_positionMap, offset.xy).xyz;
      float sampleDepth = -getDepth(samplePos);
      // 范围检查
      float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragDepth - sampleDepth));
      // 检查样本的当前深度值是否大于存储的深度值,如果是,添加到最终的贡献因子上
      occlusion += (sampleDepth >= aoDepth ? 1.0 : 0.0) * rangeCheck;
    }
    // 将遮蔽贡献根据核心的大小标准化,并输出结果
    occlusion = 1.0 - (occlusion / float(SSAO_SAMPLES_SIZE));
    fragColor = vec4(occlusion, 1.0, 1.0, 1.0);
  }
}%


下图展示了环境遮蔽着色器产生的纹理:


可见,环境遮蔽产生了非常强烈的深度感。仅仅通过环境遮蔽纹理就已经能清晰地看见模型一定躺在地板上而不是浮在空中。


现在的效果仍然看起来不是很完美,不连续的噪点清晰可见,为了创建一个光滑的环境遮蔽结果,需要模糊环境遮蔽纹理进行降噪。



应用 SSAO 纹理


最后将 SSAO 纹理进行模糊降噪,并逐片段将环境遮蔽因子乘到环境光照分量上,拷贝内置光照着色器(internal/effects/pipeline/deferred-lighting.effect)命名为 ssao-lighting.effect。

/**
* 本文改动部分添加了中文注释
*/
CCProgram lighting-fs %{
  precision highp float;
  #include <cc-global>
  #include <shading-standard-base>
  #include <shading-standard-additive>
  #include <output-standard>
  #include <cc-fog-base>


  in vec2 v_uv;

  #pragma builtin(global)
  layout (set = 0, binding = 6) uniform sampler2D cc_gbuffer_albedoMap;
  #pragma builtin(global)
  layout (set = 0, binding = 7) uniform sampler2D cc_gbuffer_positionMap;
  #pragma builtin(global)
  layout (set = 0, binding = 8) uniform sampler2D cc_gbuffer_normalMap;
  #pragma builtin(global)
  layout (set = 0, binding = 9) uniform sampler2D cc_gbuffer_emissiveMap;
  #pragma builtin(global)
  layout (set = 0, binding = 11) uniform sampler2D cc_ssaoMap;

  layout(location = 0) out vec4 fragColor;

  vec4 gaussianBlur(sampler2D Tex, vec2 UV, float Intensity)
  {
    // 省略, 详见 demo 工程
    return texture(Tex, UV);
  }

  // 屏幕展示 SSAO 纹理
  // void main() {
  //   // 降噪
  //   vec4 color = gaussianBlur(cc_ssaoMap, v_uv, 3.0);
  //   // 不降噪
  //   vec4 color = texture(cc_ssaoMap, v_uv);
  //   fragColor = vec4(vec3(color.r), 1.0);
  // }

  void main () {
    StandardSurface s;

    vec4 albedoMap = texture(cc_gbuffer_albedoMap,v_uv);
    vec4 positionMap = texture(cc_gbuffer_positionMap,v_uv);
    vec4 normalMap = texture(cc_gbuffer_normalMap,v_uv);
    vec4 emissiveMap = texture(cc_gbuffer_emissiveMap,v_uv);
    // ssao 环境遮蔽因子, 单通道纹理, 所以只取 R 通道
    vec4 ssaoMap = vec4(vec3(gaussianBlur(cc_ssaoMap, v_uv, 3.0).r), 1.0);

    s.albedo = albedoMap * ssaoMap; // 乘到辐照率贴图上, 应用遮蔽纹理
    s.position = positionMap.xyz;
    s.roughness = positionMap.w;
    s.normal = normalMap.xyz;
    s.metallic = normalMap.w;
    s.emissive = emissiveMap.xyz;
    s.occlusion = emissiveMap.w;
    // fixme: default value is 0, and give black result
    float fogFactor;
    CC_TRANSFER_FOG_BASE(vec4(s.position, 1), fogFactor);

    vec4 shadowPos;
    CC_TRANSFER_SHADOW_BASE(vec4(s.position, 1), shadowPos);

    vec4 color = CCStandardShadingBase(s, shadowPos) +
                 CCStandardShadingAdditive(s, shadowPos);

    CC_APPLY_FOG_BASE(color, fogFactor);
    fragColor = CCFragOutput(color);
  }
}%


最后来看下最终的渲染结果对比,首先是 SSAO 开启的效果:


SSAO 关闭的效果:


屏幕空间环境遮蔽是一个可高度自定义的效果,它的效果很大程度上依赖于我们根据场景类型调整它的参数。对所有类型的场景并不存在什么完美的参数组合方式。一些场景只在小半径情况下工作,又有些场景会需要更大的半径和更大的样本数量才能看起来更真实。当前这个演示用了64个样本,属于比较多的了,你可以调整核心大小和半径从而获得合适的效果。


已知问题


  • 编辑器摄像机预览会渲染不正确。

  • 资源管理器里面点击自定义管线资源文件,编辑器控制台会报错,可能会导致编辑器无响应 (目前建议没事别碰,碰过重启编辑器可恢复正常)。

  • 手机浏览器 (小米10 Pro) 下使用最大采样核心 (64) 时,帧数只有个位数,可以确定当前版本基本不能应用到实际项目中,还需优化。

  • Native 下自定义渲染管线同时还需要自定义 Engine-Native[2] 引擎,所以 Native 暂时还未支持,可参考 PR 3934[3] 添加对 Native 的支持,这里要感谢 大表姐Kristine 提供的信息。




相关教程


LearnOpenGL-CN->目录->高级光照->SSAO:https://learnopengl-cn.github.io/05%20Advanced%20Lighting/09%20SSAO/环境遮罩之 SSAO 原理:https://zhuanlan.zhihu.com/p/46633896GAMES202-高质量实时渲染(视频00:46:25开始):https://www.bilibili.com/video/BV1YK4y1T7yY?p=8


参考链接


延迟着色法[1]:

https://learnopengl-cn.github.io/05%20Advanced%20Lighting/08%20Deferred%20Shading/

Engine-Native[2]

https://github.com/cocos-creator/engine-native/tree/develop/cocos/renderer/pipeline

PR 3934[3]:

https://github.com/cocos-creator/engine-native/pull/3934




更多内容请点击文末【阅读原文】移步 Cocos 论坛专贴,欢迎大家一起交流、讨论:

https://forum.cocos.org/t/topic/122335


往期精彩

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存